
/*
 * Copyright (C) 2008-2009 Archie L. Cobbs. All rights reserved.
 *
 * $Id: AnnotationReader.java 301 2010-01-18 22:56:09Z archie.cobbs $
 */

package org.dellroad.sidekar.annotation;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

import org.apache.log4j.Logger;
import org.dellroad.sidekar.Sidekar;
import org.dellroad.sidekar.schema.model.EntityModel;
import org.dellroad.sidekar.schema.model.FieldModel;
import org.dellroad.sidekar.schema.model.IndexModel;
import org.dellroad.sidekar.schema.model.SchemaModel;
import org.dellroad.sidekar.util.MethodFinder;

/**
 * Introspects Sidekar schema definition attributes.
 */
public class AnnotationReader {

    private final Logger log = Logger.getLogger(getClass());
    private final SchemaModel schemaModel;
    private final HashMap<Class<?>, EntityModel> classMap = new HashMap<Class<?>, EntityModel>();

    /**
     * Convenience constructor. Equivalent to:
     * <blockquote><code>AnnotationReader(new SchemaModel())</code></blockquote>
     */
    public AnnotationReader() {
        this(new SchemaModel());
    }

    /**
     * Primary constructor.
     *
     * @param schemaModel the schema model to populate (will be returned by {@link #getSchemaModel})
     */
    public AnnotationReader(SchemaModel schemaModel) {
        if (schemaModel == null)
            throw new IllegalArgumentException("null schemaModel");
        this.schemaModel = schemaModel;
    }

    /**
     * Get the current {@link SchemaModel}, populated by the previous call to {@link #scan scan()}.
     *
     * @return the schema model given to the constructor
     */
    public SchemaModel getSchemaModel() {
        return this.schemaModel;
    }

    /**
     * Scan the given classes for annotations. Information gathered by any previous scan is discarded.
     *
     * @param classList list of classes to scan
     * @throws IllegalArgumentException if any annotations are invalid
     */
    public void scan(Iterable<Class<?>> classList) {

        // Initialize
        this.schemaModel.clear();

        // Compute transitive closure of classes under "superclass" operation
        HashSet<Class<?>> classes = new HashSet<Class<?>>();
        for (Class<?> c : classList) {
            do {
                classes.add(c);
                c = c.getSuperclass();
            } while (c != null);
        }

        // Now eliminate non-@SidekarEntity annotated classes
        for (Iterator<Class<?>> i = classes.iterator(); i.hasNext(); ) {
            if (!i.next().isAnnotationPresent(SidekarEntity.class))
                i.remove();
        }

        // Check for invalid entity classes
        for (Class<?> c : classes) {
            if (c.isInterface() || c.isEnum() || c.isArray() || c.isPrimitive())
                throw new InvalidAnnotationException(c.getAnnotation(SidekarEntity.class), c, "class it not a normal class");
        }

        // Gather entity classes and build maps
        for (Class<?> c : classes)
            this.addEntity(c);

        // Resolve parents
        for (EntityModel entityModel : this.schemaModel.getEntityModels().values()) {
            for (Class<?> c = entityModel.getEntityClass().getSuperclass(); c != null; c = c.getSuperclass()) {
                EntityModel parent = this.classMap.get(c);
                if (parent != null) {
                    entityModel.setParentModel(parent);
                    break;
                }
            }
        }

        // Resolve fields
        for (EntityModel entityModel : this.schemaModel.getEntityModels().values())
            processEntity(entityModel);

        // Resolve indexes
        for (EntityModel entityModel : this.schemaModel.getEntityModels().values())
            processIndexes(entityModel);

        // Validate what we got
        this.schemaModel.validate();
    }

    private void addEntity(Class<?> clazz) {

        // Create entity model
        SidekarEntity a = clazz.getAnnotation(SidekarEntity.class);
        this.log.debug("found " + a + " annotation on " + clazz);
        EntityModel entityModel = new EntityModel();
        String name = a.name().length() != 0 ? a.name() : clazz.getSimpleName();
        entityModel.setName(name);
        entityModel.setAbstract(a.isAbstract());
        entityModel.setEntityClass(clazz);

        // Check for duplicate entity type names
        EntityModel duplicate = this.schemaModel.getEntityModels().get(name);
        if (duplicate != null) {
            throw new InvalidAnnotationException(a, clazz, "duplicate entity \""
              + name + "\" (also appears on " + duplicate.getEntityClass() + ")");
        }

        // Add it
        this.schemaModel.getEntityModels().put(name, entityModel);
        this.classMap.put(clazz, entityModel);
    }

    private void processEntity(EntityModel entityModel) {

        // Get model class
        Class<?> entityClass = entityModel.getEntityClass();
        this.log.debug("scanning " + entityClass + " for annotated methods");

        // Gather all entity class methods that are not handled by the parent entity class (if any)
        Set<Method> allMethods = new MethodFinder(entityClass).getMethods();
        Set<Method> entityMethods;
        EntityModel parent = entityModel.getParentModel();
        if (parent != null) {
            entityMethods = new HashSet<Method>(allMethods);
            entityMethods.removeAll(new MethodFinder(parent.getEntityClass()).getMethods());
        } else
            entityMethods = allMethods;

        // Maintain mappings by name and method
        HashMap<String, FieldModel> fieldMap = new HashMap<String, FieldModel>();
        HashMap<Method, FieldModel> methodMap = new HashMap<Method, FieldModel>();

        // Get readers and run them
        for (AbstractReader reader : getReaders()) {
            reader.configure(this.schemaModel, this.classMap, fieldMap, methodMap, allMethods);
            reader.process(entityClass, entityMethods);
        }

        // Done
        entityModel.getFieldModels().putAll(fieldMap);
    }

    private List<AbstractReader> getReaders() {
        ArrayList<AbstractReader> list = new ArrayList<AbstractReader>();
        list.add(new FieldReader());        // @SidekarField annotations
        list.add(new SetReader());          // @SidekarSet annotations
        list.add(new MapReader());          // @SidekarMap annotations
        list.add(new ListReader());         // @SidekarList annotations
        return list;
    }

    private void processIndexes(EntityModel entityModel) {
        SidekarEntity a = entityModel.getEntityClass().getAnnotation(SidekarEntity.class);
        for (SidekarIndex sidekarIndex : a.indexes())
            entityModel.getIndexModels().put(sidekarIndex.name(), processIndexAnnotation(entityModel, sidekarIndex));
    }

    private IndexModel processIndexAnnotation(EntityModel entityModel, SidekarIndex a) {

        // Check name is valid
        try {
            Sidekar.checkValidIndexName(a.name());
        } catch (IllegalArgumentException e) {
            throw new InvalidAnnotationException(a, entityModel.getEntityClass(), e.getMessage());
        }

        // Check there is at least one field
        if (a.fields().length == 0)
            throw new InvalidAnnotationException(a, entityModel.getEntityClass(), "at least one indexed field is required");

        // Record index info
        IndexModel indexModel = new IndexModel();
        indexModel.setName(a.name());
        indexModel.getFieldNames().addAll(Arrays.asList(a.fields()));
        indexModel.setUnique(a.unique());

        // Done
        this.log.debug("found " + a + " annotation on " + entityModel.getEntityClass());
        return indexModel;
    }
}

